iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Mobile Development

《30 天 Flutter:跨平台 AI 行程規劃 App》系列 第 7

Day 7 - 不只是深色模式:讓你的 Flutter App 也有個性主題

  • 分享至 

  • xImage
  •  

今天將深入探討如何使用 ThemeExtension 來實現 Flutter 應用程式的多主題管理。多主題設計的價值遠不止於提供基本的深色與淺色模式。它更是一種強大的工具,能讓你的應用程式跳脫千篇一律的框架,注入獨特的品牌個性,並讓使用者能夠自由選擇最貼近個人風格的視覺體驗。

除此之外,多主題還能帶來更多實際價值:

  • 品牌一致性:快速切換不同品牌色彩方案,確保多產品線或跨平台應用的設計統一。
  • 在地化與活動應用:針對節慶(如聖誕、新年)或特定地區文化,提供專屬主題,拉近與使用者的距離。
  • 無障礙與可用性:設計高對比或大字體主題,讓應用對不同需求的使用者更友善。
  • 商業模式延伸:主題不僅是設計,還能成為付費解鎖或會員專屬功能,提升應用程式的商業價值。

之前,要實現多主題切換,我們可能需要手動定義多個 ThemeData 物件,並在其中重複撰寫相同的屬性。然而,當你需要自定義顏色、字體、甚至是間距等不在 ThemeData 預設屬性中的內容時,程式碼便會變得複雜且難以維護。

ThemeExtension 提供了一個優雅的解決方案:它允許你在 ThemeData 上添加任何你想要的自訂屬性,並將其與 Widget Tree 緊密整合。這不僅確保了程式碼的清晰度,更提供了以下優點:

  • 客製化主題:輕鬆地在 ThemeData 中添加任何你想要的屬性。
  • 類型安全:因為這些屬性都是在編譯時就定義好的,所以你可以在開發過程中就發現錯誤,減少運行時問題。
  • 原生整合:ThemeExtension 是 Flutter 框架的一部分,與 Theme.of(context) 完美整合,不需要額外的套件。
  • 平滑主題切換:不同於一次替換整組 ThemeDataThemeExtension 可以針對特定屬性進行細緻過渡,讓顏色或字體切換更自然。
  • 模組化維護性:可將顏色、字體、間距、陰影等設計層級拆分成多個 Extension,降低耦合度,讓團隊協作與後續維護更輕鬆。

簡單來說,它就像是為你的主題添加一個可擴充的、自訂的「工具箱」,你想要放什麼進去,都可以,並且能隨著專案成長保持結構清晰。

特性 只使用 ThemeData 使用 ThemeExtension
自訂屬性 不支援。只能使用預設屬性。 完全支援。可以擴充任何自訂屬性。
類型安全 非類型安全。容易誤用預設屬性。 類型安全。編譯時可檢查錯誤。
可擴展性 差。隨著應用複雜度增加,程式碼會變混亂。 優異。可以創建多個 Extension,結構清晰。
取用方式 Theme.of(context).colorScheme.primary Theme.of(context).extension<AppThemeColors>()!.brandColor
主題切換 顏色切換較生硬,難以自訂過渡動畫。 支援平滑的過渡動畫,效果更佳。
適用情境 簡單、小型、不含太多客製化風格的應用程式。 所有需要自訂品牌風格、多主題、或複雜設計系統的應用程式。
設計師/開發協作 只適合開發者自行調整 UI。 可對應設計稿中的「品牌色、字體階層、陰影、邊框樣式」等,讓設計系統更好落地。

好的 🙆‍♀️
我已經幫你把 建立資料夾結構 的章節插到文章 「程式碼實作」之前,完整調整後的段落如下:


建立資料夾結構

在專案中,保持清晰的資料夾結構能大幅提升可維護性與可擴充性。以下是針對多主題管理所建議的結構:

lib/
│
├─ themes/                 # 與主題相關的檔案集中管理
│   ├─ app_theme_extension.dart  # 自訂 ThemeExtension,定義顏色、字體等
│   ├─ app_themes.dart           # 多種主題定義(藍色、紫色、綠色等)
│
└─ main.dart               # 應用程式入口,載入主題並組合整體架構

程式碼實作

這個範例會建立一個可切換多種主題的頁面,並且 UI 顏色會根據當前主題自動更新。由於前面定義的主題樣式太過複雜,所以這邊有另外訂幾個主題進行示範~

步驟一:定義主題擴展 (app_theme_extension.dart)

首先,我們需要建立一個新的 Dart 檔案,來定義客製化的主題屬性。這裡我們以顏色為例,因此類別命名為 AppThemeColors,並繼承自 ThemeExtension<AppThemeColors>

// app_theme_extension.dart
import 'package:flutter/material.dart';

@immutable
class AppThemeColors extends ThemeExtension<AppThemeColors> {
  const AppThemeColors({
    required this.brandColor,
    required this.textColor,
  });

  final Color brandColor;
  final Color textColor;

  // 覆寫 copyWith 與 lerp,支援屬性更新與動畫過渡
  @override
  AppThemeColors copyWith({
    Color? brandColor,
    Color? textColor,
  }) {
    return AppThemeColors(
      brandColor: brandColor ?? this.brandColor,
      textColor: textColor ?? this.textColor,
    );
  }

  @override
  AppThemeColors lerp(ThemeExtension<AppThemeColors>? other, double t) {
    if (other is! AppThemeColors) return this;
    return AppThemeColors(
      brandColor: Color.lerp(brandColor, other.brandColor, t)!,
      textColor: Color.lerp(textColor, other.textColor, t)!,
    );
  }
}

在這個類別中,我們定義了兩個自訂顏色:brandColor 和 textColor。
關鍵在於 覆寫 copyWithlerp

  • copyWith 讓我們能靈活更新單一屬性。
  • lerp 讓 Flutter 在主題切換時能正確處理顏色漸變,避免生硬的閃跳。

步驟二:建立多種主題 (app_themes.dart)

接下來,我們定義三種不同的主題:藍色、紫色以及一個綠色主題。在這些主題中,我們將使用 ThemeDataextensions 屬性來加入我們剛剛創建的 AppThemeColors

// app_themes.dart
import 'package:flutter/material.dart';
import 'app_theme_extension.dart';

final ThemeData blueTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
  extensions: const <ThemeExtension<dynamic>>[
    AppThemeColors(
      brandColor: Colors.blue,
      textColor: Colors.black,
    ),
  ],
);

final ThemeData purpleTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple),
  extensions: const <ThemeExtension<dynamic>>[
    AppThemeColors(
      brandColor: Colors.purple,
      textColor: Colors.white,
    ),
  ],
);

final ThemeData greenTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: Colors.green),
  extensions: const <ThemeExtension<dynamic>>[
    AppThemeColors(
      brandColor: Colors.green,
      textColor: Colors.green.shade900,
    ),
  ],
);

步驟三:在應用程式中使用它 (main.dart)

最後,我們在主應用程式中實現多主題切換的邏輯。後續可以搭配 Riverpod 進行狀態管理,能有效將主題邏輯與 UI 分離,讓多主題切換的程式碼更清晰、更易於維護且效能更好。

// main.dart
import 'package:flutter/material.dart';
import 'app_theme_extension.dart';
import 'app_themes.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  final List<ThemeData> _themes = [blueTheme, darkTheme, greenTheme];
  final List<String> _themeNames = ['藍色主題', 'purpleTheme', '綠色主題'];
  final List<IconData> _themeIcons = [Icons.light_mode, Icons.dark_mode, Icons.color_lens];
  int _themeIndex = 0;

  void _toggleTheme() {
    setState(() {
      _themeIndex = (_themeIndex + 1) % _themes.length;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '多主題切換範例',
      theme: _themes[_themeIndex],
      home: ThemeSwitchPage(
        toggleTheme: _toggleTheme,
        currentThemeName: _themeNames[_themeIndex],
        currentThemeIcon: _themeIcons[_themeIndex],
      ),
    );
  }
}

class ThemeSwitchPage extends StatelessWidget {
  const ThemeSwitchPage({
    super.key,
    required this.toggleTheme,
    required this.currentThemeName,
    required this.currentThemeIcon,
  });

  final VoidCallback toggleTheme;
  final String currentThemeName;
  final IconData currentThemeIcon;

  @override
  Widget build(BuildContext context) {
    // 取得自訂的 ThemeExtension
    final AppThemeColors themeColors =
        Theme.of(context).extension<AppThemeColors>()!;
    
    return Scaffold(
      appBar: AppBar(
        title: const Text('多主題切換範例'),
        backgroundColor: themeColors.brandColor, // 使用自訂的顏色
        actions: [
          IconButton(
            icon: Icon(currentThemeIcon),
            onPressed: toggleTheme,
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              '目前主題:$currentThemeName',
              style: TextStyle(
                color: themeColors.textColor, // 使用自訂的顏色
                fontSize: 24,
                fontWeight: FontWeight.bold,
              ),
            ),
            const SizedBox(height: 20),
            Text(
              '點擊右上角的按鈕來切換主題',
              style: TextStyle(
                color: themeColors.textColor, // 使用自訂的顏色
                fontSize: 20,
              ),
              textAlign: TextAlign.center,
            ),
            const SizedBox(height: 20),
            Container(
              padding: const EdgeInsets.all(24),
              color: Theme.of(context).colorScheme.surface,
              child: Text(
                '這個卡片的背景色會隨著主題改變',
                style: TextStyle(
                  color: Theme.of(context).colorScheme.onSurface,
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

今日成果

demo trip

上一篇
Day 6 - 從 Figma 到 Flutter:將設計系統化為 UI 元件
下一篇
Day 8 - 解鎖 Dart MCP Server:開發效率瞬間提升的秘密武器
系列文
《30 天 Flutter:跨平台 AI 行程規劃 App》21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言